<!--
@license
Copyright (C) 2016 The Android Open Source Project

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<!--

How to Add a Keyboard Shortcut
==============================

A keyboard shortcut is composed of the following parts:

  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
  2. Documentation for the keyboard shortcut help dialog
  3. A binding between key combos and the semantic identifier
  4. A binding between the semantic identifier and a listener

Parts (1) and (2) for all shortcuts are defined in this file. The semantic
identifier is declared in the Shortcut enum near the head of this script:

  const Shortcut = {
    // ...
    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
    // ...
  };

Immediately following the Shortcut enum definition, there is a _describe
function defined which is then invoked many times to populate the help dialog.
Add a new invocation here to document the shortcut:

  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
      'Hide/show left diff');

When an attached view binds one or more key combos to this shortcut, the help
dialog will display this text in the given section (in this case, "Diffs"). See
the ShortcutSection enum immediately below for the list of supported sections.

Part (3), the actual key bindings, are declared by gr-app. In the future, this
system may be expanded to allow key binding customizations by plugins or user
preferences. Key bindings are defined in the following forms:

  // Ordinary shortcut with a single binding.
  this.bindShortcut(
      this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');

  // Ordinary shortcut with multiple bindings.
  this.bindShortcut(
      this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');

  // A "go-key" keyboard shortcut, which is combined with a previously and
  // continuously pressed "go" key (the go-key is hard-coded as 'g').
  this.bindShortcut(
      this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');

  // A "doc-only" keyboard shortcut. This declares the key-binding for help
  // dialog purposes, but doesn't actually implement the binding. It is up
  // to some element to implement this binding using iron-a11y-keys-behavior's
  // keyBindings property.
  this.bindShortcut(
      this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');

Part (4), the listener definitions, are declared by the view or element that
implements the shortcut behavior. This is done by implementing a method named
keyboardShortcuts() in an element that mixes in this behavior, returning an
object that maps semantic identifiers (as property names) to listener method
names, like this:

  keyboardShortcuts() {
    return {
      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
    };
  },

You can implement key bindings in an element that is hosted by a view IF that
element is always attached exactly once under that view (e.g. the search bar in
gr-app). When that is not the case, you will have to define a doc-only binding
in gr-app, declare the shortcut in the view that hosts the element, and use
iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
element. An example of this is in comment threads. A diff view supports actions
on comment threads, but there may be zero or many comment threads attached at
any given point. So the shortcut is declared as doc-only by the diff view and
by gr-app, and actually implemented by gr-diff-comment-thread.

NOTE: doc-only shortcuts will not be customizable in the same way that other
shortcuts are.
-->
<link rel="import" href="../../bower_components/polymer/polymer.html">
<link rel="import" href="../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">

<script>
(function(window) {
  'use strict';

  const DOC_ONLY = 'DOC_ONLY';
  const GO_KEY = 'GO_KEY';

  // The maximum age of a keydown event to be used in a jump navigation. This
  // is only for cases when the keyup event is lost.
  const GO_KEY_TIMEOUT_MS = 1000;

  const ShortcutSection = {
    ACTIONS: 'Actions',
    DIFFS: 'Diffs',
    EVERYWHERE: 'Everywhere',
    FILE_LIST: 'File list',
    NAVIGATION: 'Navigation',
    REPLY_DIALOG: 'Reply dialog',
  };

  const Shortcut = {
    OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
    GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
    GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
    GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',

    CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
    CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
    OPEN_CHANGE: 'OPEN_CHANGE',
    NEXT_PAGE: 'NEXT_PAGE',
    PREV_PAGE: 'PREV_PAGE',
    TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
    TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
    REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',

    OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
    OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
    EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
    COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
    UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
    UP_TO_CHANGE: 'UP_TO_CHANGE',
    TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
    REFRESH_CHANGE: 'REFRESH_CHANGE',
    EDIT_TOPIC: 'EDIT_TOPIC',

    NEXT_LINE: 'NEXT_LINE',
    PREV_LINE: 'PREV_LINE',
    NEXT_CHUNK: 'NEXT_CHUNK',
    PREV_CHUNK: 'PREV_CHUNK',
    EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
    NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
    PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
    EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
    COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
    LEFT_PANE: 'LEFT_PANE',
    RIGHT_PANE: 'RIGHT_PANE',
    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
    NEW_COMMENT: 'NEW_COMMENT',
    SAVE_COMMENT: 'SAVE_COMMENT',
    OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
    TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',

    NEXT_FILE: 'NEXT_FILE',
    PREV_FILE: 'PREV_FILE',
    NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
    PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
    NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
    CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
    CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
    OPEN_FILE: 'OPEN_FILE',
    TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
    TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
    TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',

    OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
    OPEN_LAST_FILE: 'OPEN_LAST_FILE',

    SEARCH: 'SEARCH',
    SEND_REPLY: 'SEND_REPLY',
  };

  const _help = new Map();

  function _describe(shortcut, section, text) {
    if (!_help.has(section)) {
      _help.set(section, []);
    }
    _help.get(section).push({shortcut, text});
  }

  _describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
  _describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
      '显示本窗口');
  _describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
      '跳转至已打开的提交页面');
  _describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
      '跳转至已合并的提交页面');
  _describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
      '跳转至已丢弃的提交页面');

  _describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
      '选择下一个修改');
  _describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
      '选择上一个修改');
  _describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
      '显示选中的修改');
  _describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, '跳转至下一页');
  _describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, '跳转至上一页');
  _describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
      '打开回复对话框以发布评论并添加审查人员');
  _describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
      'Open download overlay');
  _describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
      'Expand all messages');
  _describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
      'Collapse all messages');
  _describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
      '在最新的补丁中重新加载更改');
  _describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
      '标记/取消标记修改为已审阅');
  _describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
      '切换选定文件的评审标志');
  _describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
      '刷新修改列表');
  _describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
      '收藏/取消收藏修改');
  _describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
      '添加一个修改话题');

  _describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, '跳转至下一行');
  _describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, '跳转至上一行');
  _describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
      '转到下一个diff块');
  _describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
      '转到上一个diff块');
  _describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
      'Expand all diff context');
  _describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
      'Go to next comment thread');
  _describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
      'Go to previous comment thread');
  _describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
      'Expand all comment threads');
  _describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
      'Collapse all comment threads');
  _describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, '选择左侧面板');
  _describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, '选择右侧面板');
  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
      '隐藏/显示左侧diff块');
  _describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, '撰写新评论');
  _describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, '保存评论');
  _describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
      '显示diff块偏好设置');
  _describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
      '标记/取消标记文件为已审阅');
  _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
      'Toggle unified/side-by-side diff');
  _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
      '将文件标记为已审阅，然后转到下一个未审阅的文件');

  _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, '选择下一个文件');
  _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
      '选择上一个文件');
  _describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
      '选择带有注释的下一个文件');
  _describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
      '选择带有注释的上一个文件');
  _describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
      '显示第一个文件');
  _describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
      '显示最后一个文件');
  _describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
      '回到仪表盘');
  _describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, '回到修改页');

  _describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
      '选择下一个文件');
  _describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
      '选择上一个文件');
  _describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
      '转到选定文件');
  _describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
      'Show/hide all inline diffs');
  _describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
      'Show/hide selected inline diff');

  _describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, '发送回复');

  // Must be declared outside behavior implementation to be accessed inside
  // behavior functions.

  /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
  const getKeyboardEvent = function(e) {
    e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
    // When e is a keyboardEvent, e.event is not null.
    if (e.event) { e = e.event; }
    return e;
  };

  class ShortcutManager {
    constructor() {
      this.activeHosts = new Map();
      this.bindings = new Map();
      this.listeners = new Set();
    }

    bindShortcut(shortcut, ...bindings) {
      this.bindings.set(shortcut, bindings);
    }

    getBindingsForShortcut(shortcut) {
      return this.bindings.get(shortcut);
    }

    attachHost(host) {
      if (!host.keyboardShortcuts) { return; }
      const shortcuts = host.keyboardShortcuts();
      this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
      this.notifyListeners();
      return shortcuts;
    }

    detachHost(host) {
      if (this.activeHosts.delete(host)) {
        this.notifyListeners();
        return true;
      }
      return false;
    }

    addListener(listener) {
      this.listeners.add(listener);
      listener(this.directoryView());
    }

    removeListener(listener) {
      return this.listeners.delete(listener);
    }

    activeShortcutsBySection() {
      const activeShortcuts = new Set();
      this.activeHosts.forEach(shortcuts => {
        shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
      });

      const activeShortcutsBySection = new Map();
      _help.forEach((shortcutList, section) => {
        shortcutList.forEach(shortcutHelp => {
          if (activeShortcuts.has(shortcutHelp.shortcut)) {
            if (!activeShortcutsBySection.has(section)) {
              activeShortcutsBySection.set(section, []);
            }
            activeShortcutsBySection.get(section).push(shortcutHelp);
          }
        });
      });
      return activeShortcutsBySection;
    }

    directoryView() {
      const view = new Map();
      this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
        const sectionView = [];
        shortcutHelps.forEach(shortcutHelp => {
          const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
          if (!bindingDesc) { return; }
          this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
            sectionView.push({
              binding: bindingDesc,
              text: shortcutHelp.text,
            });
          });
        });
        view.set(section, sectionView);
      });
      return view;
    }

    distributeBindingDesc(bindingDesc) {
      if (bindingDesc.length === 1 ||
          this.comboSetDisplayWidth(bindingDesc) < 21) {
        return [bindingDesc];
      }
      // Find the largest prefix of bindings that is under the
      // size threshold.
      const head = [bindingDesc[0]];
      for (let i = 1; i < bindingDesc.length; i++) {
        head.push(bindingDesc[i]);
        if (this.comboSetDisplayWidth(head) >= 21) {
          head.pop();
          return [head].concat(
              this.distributeBindingDesc(bindingDesc.slice(i)));
        }
      }
    }

    comboSetDisplayWidth(bindingDesc) {
      const bindingSizer = binding => binding.reduce(
          (acc, key) => acc + key.length, 0);
      // Width is the sum of strings + (n-1) * 2 to account for the word
      // "or" joining them.
      return bindingDesc.reduce(
          (acc, binding) => acc + bindingSizer(binding), 0) +
          2 * (bindingDesc.length - 1);
    }

    describeBindings(shortcut) {
      const bindings = this.bindings.get(shortcut);
      if (!bindings) { return null; }
      if (bindings[0] === GO_KEY) {
        return [['g'].concat(bindings.slice(1))];
      }
      return bindings
          .filter(binding => binding !== DOC_ONLY)
          .map(binding => this.describeBinding(binding));
    }

    describeBinding(binding) {
      return binding.split(':')[0].split('+').map(part => {
        switch (part) {
          case 'shift':
            return 'Shift';
          case 'meta':
            return 'Meta';
          case 'ctrl':
            return 'Ctrl';
          case 'enter':
            return 'Enter';
          case 'up':
            return '↑';
          case 'down':
            return '↓';
          case 'left':
            return '←';
          case 'right':
            return '→';
          default:
            return part;
        }
      });
    }

    notifyListeners() {
      const view = this.directoryView();
      this.listeners.forEach(listener => listener(view));
    }
  }

  const shortcutManager = new ShortcutManager();

  window.Gerrit = window.Gerrit || {};

  /** @polymerBehavior KeyboardShortcutBehavior */
  Gerrit.KeyboardShortcutBehavior = [
    Polymer.IronA11yKeysBehavior,
    {
      // Exports for convenience. Note: Closure compiler crashes when
      // object-shorthand syntax is used here.
      // eslint-disable-next-line object-shorthand
      DOC_ONLY: DOC_ONLY,
      // eslint-disable-next-line object-shorthand
      GO_KEY: GO_KEY,
      // eslint-disable-next-line object-shorthand
      Shortcut: Shortcut,

      properties: {
        _shortcut_go_key_last_pressed: {
          type: Number,
          value: null,
        },

        _shortcut_go_table: {
          type: Array,
          value() { return new Map(); },
        },
      },

      modifierPressed(e) {
        e = getKeyboardEvent(e);
        return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
      },

      isModifierPressed(e, modifier) {
        return getKeyboardEvent(e)[modifier];
      },

      shouldSuppressKeyboardShortcut(e) {
        e = getKeyboardEvent(e);
        const tagName = Polymer.dom(e).rootTarget.tagName;
        if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
            (e.keyCode === 13 && tagName === 'A')) {
          // Suppress shortcuts if the key is 'enter' and target is an anchor.
          return true;
        }
        for (let i = 0; e.path && i < e.path.length; i++) {
          if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
        }
        return false;
      },

      // Alias for getKeyboardEvent.
      /** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
      getKeyboardEvent(e) {
        return getKeyboardEvent(e);
      },

      getRootTarget(e) {
        return Polymer.dom(getKeyboardEvent(e)).rootTarget;
      },

      bindShortcut(shortcut, ...bindings) {
        shortcutManager.bindShortcut(shortcut, ...bindings);
      },

      _addOwnKeyBindings(shortcut, handler) {
        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
        if (!bindings) {
          return;
        }
        if (bindings[0] === DOC_ONLY) {
          return;
        }
        if (bindings[0] === GO_KEY) {
          this._shortcut_go_table.set(bindings[1], handler);
        } else {
          this.addOwnKeyBinding(bindings.join(' '), handler);
        }
      },

      attached() {
        const shortcuts = shortcutManager.attachHost(this);
        if (!shortcuts) { return; }

        for (const key of Object.keys(shortcuts)) {
          this._addOwnKeyBindings(key, shortcuts[key]);
        }

        // If any of the shortcuts utilized GO_KEY, then they are handled
        // directly by this behavior.
        if (this._shortcut_go_table.size > 0) {
          this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
          this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
          this._shortcut_go_table.forEach((handler, key) => {
            this.addOwnKeyBinding(key, '_handleGoAction');
          });
        }
      },

      detached() {
        if (shortcutManager.detachHost(this)) {
          this.removeOwnKeyBindings();
        }
      },

      keyboardShortcuts() {
        return {};
      },

      addKeyboardShortcutDirectoryListener(listener) {
        shortcutManager.addListener(listener);
      },

      removeKeyboardShortcutDirectoryListener(listener) {
        shortcutManager.removeListener(listener);
      },

      _handleGoKeyDown(e) {
        if (this.modifierPressed(e)) { return; }
        this._shortcut_go_key_last_pressed = Date.now();
      },

      _handleGoKeyUp(e) {
        this._shortcut_go_key_last_pressed = null;
      },

      _handleGoAction(e) {
        if (!this._shortcut_go_key_last_pressed ||
            (Date.now() - this._shortcut_go_key_last_pressed >
                GO_KEY_TIMEOUT_MS) ||
            !this._shortcut_go_table.has(e.detail.key) ||
            this.shouldSuppressKeyboardShortcut(e)) {
          return;
        }
        e.preventDefault();
        const handler = this._shortcut_go_table.get(e.detail.key);
        this[handler](e);
      },
    },
  ];

  Gerrit.KeyboardShortcutBinder = {
    DOC_ONLY,
    GO_KEY,
    Shortcut,
    ShortcutManager,
    ShortcutSection,

    bindShortcut(shortcut, ...bindings) {
      shortcutManager.bindShortcut(shortcut, ...bindings);
    },
  };
})(window);
</script>
